feat(webapp): server-side agent message quota - #4552
Conversation
A chat/session belongs to one (org, user) pair. Pin that every store read — getChatMessages, getSession, chatExists, listChats, countUserMessages — and appendChatMessageOnce refuse a foreign tenant, so a chatId from another org reads as not-found and never leaks a transcript or the session's public access token. TRI-11166.
The primitive resumes a turn by replaying its snapshot; pin the store seam that replay lands on: a streamed-then-resumed turn is not double-appended, a crash mid-turn keeps the mid-turn append and rebuilds the session cursor, a failed write commits nothing and the retry replays with no loss, and an OOM restart replays idempotently. TRI-11166.
Enforce the Free plan's agent-message allowance on the server, not just as a client hint. A per-(organizationId, period) counter lives in its own table, not joined to chats, so deleting a chat can no longer free quota inside the period. The create path and the .in append path each count one user message and refuse over the cap with a typed 403 the client renders as the upgrade block; wakes (action turns) never count. Fails open: an absent limit (self-hosted, or before the cloud side ships) or a counter read that throws means no cap. TRI-12863.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
WalkthroughAdds monthly, organization-level agent message quotas with UTC billing periods. Stores usage in a new database table with atomic increment queries. Applies quota checks during chat creation and message forwarding, and records counted messages. Updates dashboard agent chat to process quota refusals, refresh usage, and display an upgrade block with the applicable limit. Adds PostgreSQL-backed tests for threshold, period, persistence, resolver, and error-handling behavior. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…o test/chat-agent-durability-tri-11166
…1166' into feat/agent-message-quota-tri-12863
@trigger.dev/build
trigger.dev
@trigger.dev/core
@trigger.dev/python
@trigger.dev/react-hooks
@trigger.dev/redis-worker
@trigger.dev/rsc
@trigger.dev/schema-to-json
@trigger.dev/sdk
commit: |
…o test/chat-agent-durability-tri-11166
A failed upstream send (5xx/502) or a non-2xx response burned a quota message that never reached the agent. Record only after upstream.ok.
Draft submit and chat retry now bail when the message cap is reached, so a suggested prompt or retry over the cap no longer fires a silent 403. The capped draft keeps any open watch card. The quota re-reads when a turn settles instead of on optimistic append, so the count and cap no longer lag by one message.
…constraint violation
| // Suggested prompts reach here via the hero, bypassing the composer's cap guard. | ||
| if (capReached) return; |
There was a problem hiding this comment.
🟡 Suggested prompt buttons do nothing once the message allowance is used up
The suggested-prompt buttons on the empty-chat screen stay clickable but their click is silently discarded (if (capReached) return; at apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx:50), so clicking one produces no reaction at all.
Impact: A user who has hit the allowance clicks a suggestion and nothing happens, with no explanation.
Hero prompts remain rendered while the composer is replaced
When capReached is set the composer slot is swapped for AgentUpgradeBlock (apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx:65-78), but DashboardAgentHero still renders its prompt buttons wired to onSelect={submit}, and submit now returns early. The same shape exists in apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx:397-403 where the hero is shown for an empty transcript while submit bails on atMessageCap. Disabling/hiding the prompt buttons (or surfacing the upgrade block on click) would make the state legible.
Prompt for agents
In apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx the new capReached guard makes submit a no-op, but DashboardAgentHero still renders clickable suggested-prompt buttons (onSelect={submit}), so clicking one silently does nothing. The same applies to the hero rendered from DashboardAgentChat.tsx when atMessageCap is true and the transcript is empty. Consider passing a disabled/hidden state down to the hero's prompt buttons when the allowance is reached, so the UI matches the upgrade block that replaced the composer.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // The upgrade block's sentence. Pure so the copy is asserted directly, and so the raw | ||
| // server code can never be what the user reads. | ||
| export function messageQuotaReachedCopy(limit: number): string { | ||
| return `You've used all ${limit} messages included on the Free plan. Your chats stay here to read.`; | ||
| } |
There was a problem hiding this comment.
🔍 Upgrade copy hard-codes "Free plan" but the server refuses any org over its plan limit
resolveAgentMessageQuota reads the agentMessages limit for every org, not just free ones, so once the cloud side ships a number for paid plans a paying org that exhausts its allowance also gets 403 message_quota_reached. The client renders that through AgentUpgradeBlock, whose heading is "Upgrade to unlock …" and whose body is messageQuotaReachedCopy (apps/webapp/app/components/dashboard-agent/message-quota.ts:48-50) — "You've used all N messages included on the Free plan." A paying customer would be shown an upsell and an incorrect plan name. Consider having the server say which plan the limit came from, or making the copy plan-neutral when the user isn't on the free plan.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // Over the cap, a retry only earns another 403 — same guard as `submit`. | ||
| if (atMessageCap) return; |
There was a problem hiding this comment.
🟡 Retry button silently does nothing once the allowance is exhausted
The retry action is turned into a no-op whenever the allowance is exhausted (if (atMessageCap) return; at apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx:274-275), even for a re-run that the server does not charge, so clicking Retry on a failed answer appears to do nothing.
Impact: A user whose last answer failed can never recover it once they hit the allowance — the retry button just doesn't respond.
Regenerate is not charged server-side, but is blocked client-side
retryAction can resolve to a regenerate, which the transport sends with trigger: "regenerate-message". The server explicitly does not count that against quota (agentTurnCountsAgainstQuota in apps/webapp/app/services/dashboardAgentQuota.server.ts:97-101), and the .in proxy only returns the 403 refusal when countsAgainstQuota is true (apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$.ts:140-149). So the in-code comment ("a retry only earns another 403") is only true for the resend branch; the regenerate branch would succeed.
Meanwhile the error banner with its Retry button is still rendered at the cap (DashboardAgentChat.tsx:404-414), so the button is visible but inert with no feedback. Gating only the resend branch — or hiding/disabling the retry affordance at the cap — would avoid the dead control.
Was this helpful? React with 👍 or 👎 to provide feedback.
**What & why.** Watches now honour a plan's watch limits. A watch whose window exceeds the plan's `agentWatchMaxHours`, or that would push the org past its `agentWatchers` count, is refused with a new `watch_limit_reached` result and an upgrade hint (a chat line on the card, HTTP 409 on the API). **Key decisions.** - Plan limits are a floor *below* the existing code ceilings: `min(plan, WATCH_MAX_HOURS=24)` for the window, and the per-chat cap of 3 still applies independently. Plans only tighten, never loosen. - Watcher count is org-wide and checked only after the immediate check declines, so a one-shot consumes no slot. - Fails open: an absent limit resolves to the unlimited sentinel, so self-hosted is unaffected; the upgrade nudge is gated on `isBillingConfigured()`. - Follow-up: quiet Pro-mark on the card's long-window options. TRI-12863
What & why
The Free-plan agent-message allowance was only a client-side hint (
useIsFreePlan()was hardcoded toundefined, so nothing was enforced) and the running count was a liveCOUNT(*)overchat_messages— which meant deleting a chat silently freed quota. This PR makes the allowance a real server-side limit with a durable counter, so the cap holds regardless of the client and a deleted chat can't reclaim messages within the period.The cloud side that fills the actual per-plan number is a separate PR (TRI-12863 P0). Until it deploys the
agentMessageslimit key is absent, which resolves to the repo's unlimited sentinel — the correct fail-open default, and why this ships independently.What's inside
trigger_dashboard_agent.agent_message_usage, keyed(organization_id, period)whereperiodis a UTC calendar month"YYYY-MM". FK-free (pgSchemaconvention), plus a drizzle migration. Deliberately not joined to chats — that closes the delete-a-chat hole. Distinct from TRI-13068'sAiUsageEvent; neither is derived from the other.dashboardAgentQuota.server.ts: a purecheckAgentMessageQuota({ used, limit })(so the MCP send path can reuse the rule later) plus an org-scoped resolver that reads the period counter and the cached plan limit, and arecordAgentMessageSentincrement..inappend path each increment one user message. The append path counts only after the existingtrigger === "action"403, so wakes never count.403 { error: "message_quota_reached", limit }, which the client renders asAgentUpgradeBlockrather than a generic failure. Never a silent drop.?quota=1now reads the period counter;useIsFreePlan()is a real read gated on billing presence (no subscription → self-hosted → no cap, no upgrade UI), not on the plan value.Key decisions
100_000_000(neverInfinity— that serializes tonullin the Redis limit cache), and a counter read that throws returns "no cap". Self-hosted needs zero extra branching — it falls out of the fallback, with a test to prove it.(org, period)table closes the delete hole. A standalone counter, not a count over chat rows, so deleting a chat can't free quota inside the period.Testing
apps/webapp/test/dashboardAgentQuota.test.ts(testcontainers, no mocks):checkAgentMessageQuotaunder/at/over/unlimited (control-breaks the>=);agentTurnCountsAgainstQuotacounts a message but not a wake (control-breaks the wake exclusion);TRI-12863